fix(cli): baseline suppression in recursive multi-skill scans (#201)#205
fix(cli): baseline suppression in recursive multi-skill scans (#201)#205wernerkasselman-au wants to merge 2 commits into
Conversation
…port The recursive summary table and combined JSON report computed finding_count as `filtered_findings or findings`, which falls through to the unfiltered findings when suppression empties filtered_findings, since an empty list is falsy. A fully baselined sub-skill then showed score 0 but a non-zero finding_count. Add _result_finding_count(), which falls back to raw findings only when filtered_findings is absent (the report node always sets it, possibly empty after suppression), and use it at both the summary table and the combined JSON report. Add a regression test asserting the combined JSON finding_count is 0 for a fully suppressed sub-skill. Surfaced by an independent review of NVIDIA#205. Refs NVIDIA#201 Signed-off-by: Werner Kasselman <[email protected]>
rng1995
left a comment
There was a problem hiding this comment.
Approving — fixes #201 by threading --baseline/--show-suppressed into the recursive multi-skill path (they were silently dropped). Also a real correctness fix: _result_finding_count() replaces filtered_findings or findings, which mis-counted a fully-suppressed skill (empty list is falsy, so the or fell through to the unfiltered findings). Both behaviors are covered by new tests.
Non-blocking: this and #209 both edit _scan_multi_skill — whichever merges second will need a small rebase.
|
Please resolve merge conflicts @wernerkasselman-au |
…cans _scan_multi_skill did not accept or forward the suppression options, and the recursive dispatch in scan() did not pass them, so scan --recursive silently ignored --baseline and --show-suppressed. Suppressed findings still counted toward each sub-skill's score and could flip the exit code to 1, breaking the incremental baseline workflow precisely in the mode meant for skill collections. Add baseline and show_suppressed parameters to _scan_multi_skill, pass them from the recursive dispatch, and forward them into the per-skill _scan_state call, mirroring the single-skill path. Add a regression test that captures the per sub-skill graph state and asserts the baseline and show_suppressed are present. Refs NVIDIA#201 Signed-off-by: Werner Kasselman <[email protected]>
b45b4ee to
ccfbdf4
Compare
…port The recursive summary table and combined JSON report computed finding_count as `filtered_findings or findings`, which falls through to the unfiltered findings when suppression empties filtered_findings, since an empty list is falsy. A fully baselined sub-skill then showed score 0 but a non-zero finding_count. Add _result_finding_count(), which falls back to raw findings only when filtered_findings is absent (the report node always sets it, possibly empty after suppression), and use it at both the summary table and the combined JSON report. Add a regression test asserting the combined JSON finding_count is 0 for a fully suppressed sub-skill. Surfaced by an independent review of NVIDIA#205. Refs NVIDIA#201 Signed-off-by: Werner Kasselman <[email protected]>
|
Rebased onto current Local verification: |
|
Hi @rng1995, Both are sorted on my side. For #205, I have rebased onto current For #207, I have closed it as obsolete. The ruff 0.15.2 formatting it applied is already on main, Thanks, |
|
@rng1995 when you have a moment, could you approve the workflow run on this PR? It is a fork PR, so the CI is sitting at |
rng1995
left a comment
There was a problem hiding this comment.
[Automated SkillSpector Review]
Summary: Fixes #201 — in scan --recursive, --baseline and --show-suppressed were accepted but silently dropped. The PR threads both options through _scan_multi_skill() into the per-skill _scan_state() call, and adds _result_finding_count() to fix the falsy empty-list fallback (filtered_findings or findings) that over-reported the finding count for fully suppressed sub-skills. Both fixes are correct and covered by meaningful regression tests (test_cli_recursive_forwards_baseline_and_show_suppressed, test_cli_recursive_json_finding_count_excludes_suppressed); I verified all tests in the touched files pass against this head (23/23). Combined JSON schema is preserved. (This review supersedes the earlier automated approval, which predates the rebase to the current head.)
Blocker
- Unhandled exception + wrong exit code for a bad
--baselinepath in recursive mode._scan_multi_skill()is dispatched before thetry/exceptinscan(), and_scan_state()(which callsload_baseline()) runs outside the per-skilltryblock. Verified empirically:scan <collection> -r --baseline /nonexistent.yamlprints a raw traceback and exits 1 — the "risk found" code — while the single-skill path printsError: Baseline file not found: ...and exits 2. For a CI-gating security tool this conflates an operator typo with a security finding, and it contradicts the comment in_scan_state("mapped to exit code 2 by scan()"). Suggested fix: load the baseline once inscan()inside the existingtry(passing the loadedBaselinedown), or wrap the recursive dispatch in equivalentFileNotFoundError/ValueErrorhandling. Loading once also fixes the related inefficiency below.
Non-blocking
load_baseline(baseline)is re-read and re-parsed once per sub-skill (N times for N skills). Load once and pass the parsedBaselineobject.- Cross-sub-skill suppression semantics are worth documenting: fingerprints hash
rule_id|file|lines|messagewithfilerelative to each sub-skill root, so a fingerprint generated against one sub-skill will suppress an identical finding at the same relative path in a sibling sub-skill; conversely, path-scoped rules cannot target a specific sub-skill in recursive mode (the sub-skill prefix is not infinding.file). Consistent with single-skill semantics, but a maintainer may want a doc note or a follow-up to prefixrelative_path. - The same falsy-fallback pattern the PR fixes still exists in the
baselinecommand (cli.py~line 542) andmcp_server.py:108;_result_finding_count()could be reused there in a follow-up.
The core change is good and well-tested — once the baseline error path is handled to match the single-skill contract, this is ready.
| no_llm, | ||
| yara_rules_dir, | ||
| verbose, | ||
| baseline=baseline, |
There was a problem hiding this comment.
Blocker: this dispatch happens before the try/except in scan(), and load_baseline() inside _scan_state() can raise FileNotFoundError/ValueError. Verified: scan <dir> -r --baseline /nonexistent.yaml emits a raw traceback and exits 1 (the "risk found" code), while the single-skill path prints a clean error and exits 2. Please load the baseline once here inside the existing try (or wrap this call in equivalent handling) so recursive mode keeps the documented exit-code contract.
| format, | ||
| no_llm, | ||
| yara_rules_dir=yara_dir, | ||
| baseline=baseline, |
There was a problem hiding this comment.
load_baseline() runs here once per sub-skill, re-reading and re-parsing the same file N times. Load it once in scan() (which also fixes the unhandled-exception blocker) and pass the parsed Baseline object down.
| } | ||
|
|
||
|
|
||
| def _result_finding_count(result: dict[str, object]) -> int: |
There was a problem hiding this comment.
Good fix for the falsy empty-list fallback. Note the same filtered_findings or findings pattern remains in the baseline command (~line 542) and mcp_server.py:108 — consider reusing this helper there in a follow-up.
…port The recursive summary table and combined JSON report computed finding_count from filtered_findings, but the report node returns filtered_findings as the full pre-partition set (kept plus baseline-suppressed) and lists the suppressed subset separately under suppressed_findings; it never reduces filtered_findings to active-only. Counting len(filtered_findings) therefore reported pre-suppression totals, so a baselined sub-skill showed score 0 but a non-zero finding_count, disagreeing with the per-skill report body and risk score. Add _result_finding_count(), which returns len(filtered_findings) - len(suppressed_findings) when filtered_findings is a list (partition_findings guarantees kept + suppressed == filtered_findings, so this is the active count), and falls back to the raw findings length only when filtered_findings is absent or not a list, without subtracting there since raw findings are not the population that produced suppressed_findings. Use it at both the summary table and the combined JSON report. Add a regression test covering a fully suppressed sub-skill (active 0) and a partially suppressed one (active 1), plus a unit test exercising each branch of the helper. Refs NVIDIA#201 Signed-off-by: Werner Kasselman <[email protected]>
ccfbdf4 to
b93da63
Compare
What this fixes
Closes #201. The suppression work (#88) and the multi-skill work (#136) did not
compose in
scan --recursive. This PR carries two related fixes.The suppression options were silently dropped.
--baselineand--show-suppressedwere accepted on the command line and then not threadedinto the recursive path, so a baselined finding was not actually suppressed:
it still counted toward each sub-skill's risk score and could still flip the
exit code to 1. The single-skill path honoured both options, so only the
recursive path regressed, which is exactly the mode you reach for to scan a
whole skill collection in one pass.
The recursive report over-counted findings. Even with suppression
threaded through, the multi-skill summary table and the combined JSON report
counted
filtered_findings, which the report node returns as the fullpre-partition set (kept plus baseline-suppressed); it never reduces that list
to active-only. So the count reported pre-suppression totals: a fully
baselined sub-skill showed score 0 but a non-zero
finding_count,disagreeing with its own report body and risk score.
The change
Threading (065d8fd).
_scan_multi_skill()now acceptsbaselineandshow_suppressed, the recursive dispatch inscan()passes them, and they areforwarded into the per-skill
_scan_state(...)call, mirroring the single-skillpath.
Counting (b93da63). New
_result_finding_count()helper computes the activecount as
len(filtered_findings) - len(suppressed_findings)whenfiltered_findingsis a list (partition_findingsguaranteeskept + suppressed == filtered_findings), and falls back to the rawfindingslength only when
filtered_findingsis absent or not a list, withoutsubtracting there since raw findings are not the population that produced
suppressed_findings. It is used at both the summary table and the combinedJSON report.
Tests
test_cli_recursive_forwards_baseline_and_show_suppressed: builds atwo-sub-skill collection, generates a real baseline against one sub-skill,
then captures the state handed to the graph per sub-skill and asserts the
baseline was loaded and
show_suppressedis set. On the old code the statecarried neither key.
test_cli_recursive_json_finding_count_excludes_suppressed: mirrors the realreport return shape (
filtered_findingsholds every finding,suppressed_findingsthe suppressed subset) across a fully suppressedsub-skill (active 0) and a partially suppressed one (active 1). On the old
code both counted as 3.
test_result_finding_count_branches: unit-covers every branch of the helper(normal, no-baseline, raw fallback, non-list, empty, malformed clamp).
Refs #201